Skip to content

fix(auth): bind admin-created users to the sole organization#2882

Merged
os-zhuang merged 2 commits into
mainfrom
claude/single-org-user-membership-pgn4ah
Jul 13, 2026
Merged

fix(auth): bind admin-created users to the sole organization#2882
os-zhuang merged 2 commits into
mainfrom
claude/single-org-user-membership-pgn4ah

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Problem

Creating a user via Setup → Users → Create User (POST /api/v1/auth/admin/create-user) built a login-capable account but never wrote a sys_member row. The invite (organization/invite-member) and add-member (organization/add-member) flows do create membership. So the two "add a teammate" paths diverged: in a single-org deployment the admin-created user did not "belong to" the Default Organization and was missing from the Members list — while invited users showed up. That inconsistency is what this PR resolves.

Note: in single-org mode membership does not gate data access (no OrgScopingPlugin, the tenant_isolation RLS wildcard is stripped — RBAC permission sets gate access), so this was a UX/consistency wart, not a security hole. But the missing membership was still confusing and out of step with the invite flow.

Change

1. Tactical fix — runAdminCreateUser binds membership

runAdminCreateUser now binds the newly-created user to the organization when it is unambiguous — i.e. exactly one sys_organization exists (single-org, including plugin-auth's default-org bootstrap, ADR-0081 D1):

  • Single-org (1 org): insert sys_member { organization_id, user_id, role: 'member' }.
  • Multi-org (≥2 orgs): no-op — the active-org-aware invite / add-member endpoints own membership there, and guessing an org from this header-less server call would risk the wrong one.
  • Zero orgs (org capability off): no-op.

The bind is:

  • idempotent — checks for an existing (organization_id, user_id) membership before inserting, so retries/races don't trip the unique index;
  • best-effort — a failure logs a warning and never fails account creation;
  • observable — the outcome is surfaced in the response data and the audit metadata (organizationId, membershipCreated).

This mirrors the existing pattern in ensure-default-organization.ts, which already inserts sys_member directly under a system context.

2. Strategic proposal — ADR-0093 (Proposed)

The bug is one instance of two structural gaps, so this PR also carries the ADR that proposes closing them for good:

docs/adr/0093-tenancy-mode-and-membership-lifecycle.mdTenancy mode as a first-class capability, and a single owner for the user→membership lifecycle. Key decisions (Proposed, for review):

  • D1/D2 — the "every new user gets a membership" invariant gets ONE owner: a membership reconciler composed into better-auth's user.create.after hook (covers create-user, import-users, signup, SSO JIT — present and future paths), with an explicit membershipPolicy: 'auto' | 'invite-only'. Host hooks chain first and win; the reconciler yields to existing memberships.
  • D3/D4 — a tenancy kernel service (mode, isolationActive, requested, degraded, defaultOrgId()) becomes the single source of truth, retiring the four ambient signals (env flag, org-scoping probe, org row-counting — including this PR's own heuristic — and frontend feature flags re-derivation).
  • D5 — the silent degraded state (OS_MULTI_ORG_ENABLED=true but @objectstack/organizations missing → tenant RLS stripped with only a console warning) fails fast at boot, with an explicit OS_ALLOW_DEGRADED_TENANCY=1 escape hatch that brands the deployment degraded end-to-end.
  • D6 — bounded idempotent backfill for pre-existing member-less users (single-org only).
  • D8 — explicit non-goals: single-org membership still does not gate data access; RLS strip semantics and the dual feature flags keep their meaning.

Phase 2 of the ADR retires this PR's endpoint-level bind in favor of the reconciler; the two are safely double-covered in the interim (both idempotent, both yield-to-existing).

Files

  • packages/plugins/plugin-auth/src/admin-user-endpoints.ts — add bindUserToSoleOrganization (+ findRows / genMemberId helpers), an optional find on the AdminUserDataEngine surface, and wire the bind into runAdminCreateUser.
  • packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts — cover single-org bind, multi-org no-op, idempotency, bind-failure isolation, and the no-find no-op.
  • docs/adr/0093-tenancy-mode-and-membership-lifecycle.md — the strategic proposal above (Status: Proposed).

Testing

  • vitest run src/admin-user-endpoints.test.ts29 passed (24 existing + 5 new).
  • The endpoint wiring (auth-plugin.ts) already passes the full ObjectQL engine through getDataEngine(), which exposes find, so no route change was needed.

Out of scope / follow-up

  • The bulk import-users flow (admin-import-users.ts) creates users the same way; per ADR-0093 Phase 2 it gets covered by the reconciler rather than another endpoint-level patch.
  • ADR-0093 Phases 1–3 (tenancy service, fail-fast, reconciler, backfill) land as separate PRs once the ADR is accepted.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU

`/admin/create-user` built a login-capable account but never wrote a
`sys_member` row, unlike the invite / add-member flows. In a single-org
deployment the new user therefore did not "belong to" the Default
Organization and was missing from the Members list, even though both are
just "add a teammate".

Bind the created user to the organization when it is unambiguous (exactly
one `sys_organization` — single-org, incl. plugin-auth's default-org
bootstrap). Multi-org (>=2 orgs) is left untouched: there the
active-org-aware invite / add-member endpoints own membership. The bind is
idempotent, best-effort (never fails account creation), and its outcome is
surfaced in the response + audit metadata (organizationId,
membershipCreated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU
@vercel

vercel Bot commented Jul 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 13, 2026 12:58pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth.

9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/getting-started/cli.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…bership lifecycle

Proposes closing the two structural gaps behind the create-user membership
bug (PR #2882): (1) the "every new user gets a membership" invariant gets a
single owner — a membership reconciler composed into better-auth's
user.create.after hook, with an explicit membershipPolicy (auto/invite-only);
(2) tenancy mode becomes a first-class `tenancy` kernel service consumed by
SecurityPlugin / SQL driver / auth config, retiring ambient re-derivation
(env flag, service probe, org row-counting). Degraded tenancy
(multi-org requested, enterprise package missing) fails fast instead of
silently stripping tenant RLS, with an explicit OS_ALLOW_DEGRADED_TENANCY
escape hatch. Includes backfill plan, edge-case ledger, non-goals, and a
4-phase rollout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU
@github-actions github-actions Bot added documentation Improvements or additions to documentation size/l and removed size/m labels Jul 13, 2026
@os-zhuang os-zhuang marked this pull request as ready for review July 13, 2026 13:38
@os-zhuang os-zhuang merged commit 7ee116e into main Jul 13, 2026
16 checks passed
@os-zhuang os-zhuang deleted the claude/single-org-user-membership-pgn4ah branch July 13, 2026 13:38
This was referenced Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants